Skip to content

Super Token Yield Backends: Aave & ERC4626#2125

Merged
hellwolf merged 51 commits intodevfrom
2025-12-yield
Jan 23, 2026
Merged

Super Token Yield Backends: Aave & ERC4626#2125
hellwolf merged 51 commits intodevfrom
2025-12-yield

Conversation

@d10r
Copy link
Collaborator

@d10r d10r commented Dec 11, 2025

No description provided.

@github-actions
Copy link

github-actions bot commented Dec 11, 2025

Changelog Reminder

Reminder to update the CHANGELOG.md for any of the modified packages in this PR.

  • CHANGELOG.md modified
  • Double check before merge

uint256 actualUpgradedAmount = amountAfter - amountBefore;
if (underlyingAmount != actualUpgradedAmount) revert SUPER_TOKEN_INFLATIONARY_DEFLATIONARY_NOT_SUPPORTED();

if (address(_yieldBackend) != address(0)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

move to _burn and _mint, so that selfBurn/selfMint could work too (for SETH).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 reasons against that:

  • would need to add an underlyingAmount argument (or duplicate the calculation)
  • in the selfBurn path we want to set a flag which avoids the withdrawn ETH to be wrapped to ETHx

function withdrawSurplus(uint256 totalSupply) external {
// totalSupply is always 18 decimals while assetToken and aToken may not
(uint256 normalizedTotalSupply,) = ISuperToken(address(this)).toUnderlyingAmount(totalSupply);
// decrement by 1 in order to offset Aave's rounding up
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find a reference to make it more precise.

// totalSupply is always 18 decimals while assetToken and aToken may not
(uint256 normalizedTotalSupply,) = ISuperToken(address(this)).toUnderlyingAmount(totalSupply);
// decrement by 1 in order to offset Aave's rounding up
uint256 surplusAmount = A_TOKEN.balanceOf(address(this)) - normalizedTotalSupply - 1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take into account the stray balances of the underlying, forwhatever the reason it came about.

function enableYieldBackend(IYieldBackend newYieldBackend) external onlyAdmin {
require(address(_yieldBackend) == address(0), "yield backend already set");
_yieldBackend = newYieldBackend;
(bool success, ) = address(_yieldBackend).delegatecall(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider an internal library boilerplate to hide the cruft away from the main super token logic.

function depositMax() external {
uint256 amount = USING_WETH ?
address(this).balance :
ASSET_TOKEN.balanceOf(address(this));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check scaledBalanceOf


  /**
   * @notice Returns the scaled balance of the user.
   * @dev The scaled balance is the sum of all the updated stored balance divided by the reserve's liquidity index
   * at the moment of the update
   * @param user The user whose balance is calculated
   * @return The scaled balance of the user
   */
  function scaledBalanceOf(address user) external view returns (uint256);

@d10r
Copy link
Collaborator Author

d10r commented Dec 16, 2025

Aave rounding issue

When depositing X assetToken into an Aave pool, X aToken get minted.
When immediately (no time to accrue interest) withdrawing X assetToken from an Aave pool, the burned aToken amount can be X (most of the time) or X+1 (sometimes) or X+2 (rarely).
Here a log of a fork test trying random numbers, using exponential distribution, depositing between 2 (wei) and 100M USDC:

  === Iteration 366 ===
  assetAmount requested: 100000000 0
  assetAmount received: 100000000 0
  aTokenAmount decrease: 100000000 1
  diff (requested - received):      0
  diff (received - aTokenDecrease): 1
  Bound result 44
  === Iteration 367 ===
  assetAmount requested: 21572363 539494
  assetAmount received: 21572363 539494
  aTokenAmount decrease: 21572363 539495
  diff (requested - received):      0
  diff (received - aTokenDecrease): 1
  Bound result 1
  === Iteration 368 ===
  assetAmount requested: 0 2
  assetAmount received: 0 2
  aTokenAmount decrease: 0 2
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 7
  === Iteration 369 ===
  assetAmount requested: 0 174
  assetAmount received: 0 174
  aTokenAmount decrease: 0 175
  diff (requested - received):      0
  diff (received - aTokenDecrease): 1
  Bound result 36
  === Iteration 370 ===
  assetAmount requested: 77869 556422
  assetAmount received: 77869 556422
  aTokenAmount decrease: 77869 556422
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 19
  === Iteration 371 ===
  assetAmount requested: 0 940007
  assetAmount received: 0 940007
  aTokenAmount decrease: 0 940007
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 35
  === Iteration 372 ===
  assetAmount requested: 49850 230849
  assetAmount received: 49850 230849
  aTokenAmount decrease: 49850 230849
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 34
  === Iteration 373 ===
  assetAmount requested: 18569 528966
  assetAmount received: 18569 528966
  aTokenAmount decrease: 18569 528966
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 8
  === Iteration 374 ===
  assetAmount requested: 0 368
  assetAmount received: 0 368
  aTokenAmount decrease: 0 368
  diff (requested - received):      0
  diff (received - aTokenDecrease): 0
  Bound result 10
  === Iteration 375 ===
  assetAmount requested: 0 1782
  assetAmount received: 0 1782
  aTokenAmount decrease: 0 1784
  diff (requested - received):      0
  diff (received - aTokenDecrease): 2

(18569 528966 means 18569.528966 USDC)

Why does this happen?

Aave uses continuously compounding interest for deposits.
That's a problem not too dissimilar from what the GDA is doing.
While the GDA keeps track of per-user and total units, and derives distributed amounts/flowrates proportionally, Aave pools keep track of shares and of a liquidityIndex.

When an Aave asset pool is initialized, 1 share = 1 assetToken.
Then with time going on and interest accruing, this relationship keeps shifting: 1 share = r*1 assetTokens.
r is the multiplier expressing the principal + interest somebody depositing at day one would have accrued. E.g. the USDC pool on Base now has r = 1.1179...
This number is stored with 27 digits and named liquidityIndex. It can be queried via IPool.getReserveNormalizedIncome(). E.g.

$ cast call --rpc-url https://mainnet.base.org 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5 "getReserveNormalizedIncome(address)" 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 | xargs hex2dec 
1117935072554780784462200675

Divided by 1e27 (named RAY), this is 1.1179...

The user facing unit is not shares (which keep depegging further and further from the assetToken, but aTokens.
aTokens are always pegged to the assetToken 1:1. That's achieved by continuously scaling (aka rebasing) them with r.

When a user requests withdrawal of x assetTokens, Aave calculates the corresponding shares by dividing by r, rounding up (favoring the protocol).
The rounding error introduced here is currently capped at 1 (wei), but can grow with r.
With r <= 1, the max rounding error is 1, with r <= 2 it's 2 etc.
For USD at current yields, it would take over a decade to transition to an r which allows this rounding error to become 2 (meaning 100% compounded yield since pool creation).

So, why do we already see some cases of the diff between aToken amount and assetAmount being 2?
That's because of a 2nd source of rounding error: when subtracting shares from the total shares held by the user, the protocol is again rounding such that it favours the protocol. This error is capped at 1, regardless of r.

Economics of griefing attack

So, a user could grief SF by continuously downgrading/withdrawing choosing the amounts such that the (current) max rounding error of 2 wei per operation is lost.
The cost of doing so:

> gasPerIteration = 200000
200000
> gasPrice = 0.0005 * 1e9
500000
> ethPrice=3000
3000
> usdPricePerIteration = gasPerIteration * gasPrice * ethPrice / 1e18
0.0003
> lossPerIteration = 2/1e6
0.000002
> costPerUsdLoss = 1/lossPerIteration * usdPricePerIteration
150
> maxGasPerHour = 62500000 * 3600 / 2
112500000000
> maxLossPerHour = maxGasPerHour / gasPerIteration * lossPerIteration
1.125
> yieldPerHour = curTotalSupply * avgAPR / (365 * 24)
1.36986301369863
> minTxCostPerHour = maxGasPerHour * gasPrice * ethPrice / 1e18
168.75

In order to cause a loss of 1 USDC, a griefer would need to spend min 150$ worth of tx fees with the gas price at 0.0005 gwei (current floor price of Base - assuming no blob cost).

Note that the griefer would not get the 1 USDC, it would just withhold it from the SF protocol, at a high cost.

Even if somebody would go and do that, this could happen in the following unrealistic, but theoretically possible worst case scenario:

  • utilize the full sustained (EIP-1559-targeted utilization) Base gas capacity of 62,500,000 gas per block
  • pay just the floor gas price
    This assumes there's ZERO other transactions on Base.

In this scenario the griefer could withhold ~1.125 USDC from SF protocol by using all Base block space to accumulate rounding errors with repeated downgrade transactions.

And even if this were to happen, it would still not be enough to offset the yield generated by Aave for SF with the current USDCx supply of ~400k.

For asset tokens with 18 decimals (e.g. WETH) the amounts would be smaller by many orders of magnitude.

@d10r d10r marked this pull request as ready for review January 12, 2026 20:28
@hellwolf hellwolf changed the title [WIP] yield backend yield backend Jan 13, 2026
@hellwolf hellwolf changed the title yield backend yield backends: Aave & erc4626 Jan 13, 2026
@hellwolf hellwolf changed the title yield backends: Aave & erc4626 Super Token Yield Backends: Aave & ERC4626 Jan 13, 2026
@superfluid-org superfluid-org deleted a comment from codecov bot Jan 14, 2026
@codecov
Copy link

codecov bot commented Jan 14, 2026

Codecov Report

❌ Patch coverage is 82.14286% with 20 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...racts/contracts/superfluid/AaveETHYieldBackend.sol 53.33% 14 Missing ⚠️
...es/ethereum-contracts/contracts/libs/CallUtils.sol 0.00% 4 Missing ⚠️
...reum-contracts/contracts/superfluid/SuperToken.sol 94.28% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@hellwolf hellwolf merged commit a7ee248 into dev Jan 23, 2026
26 of 27 checks passed
@hellwolf hellwolf deleted the 2025-12-yield branch January 23, 2026 10:17
@github-actions
Copy link

XKCD Comic Relif

Link: https://xkcd.com/2125
https://xkcd.com/2125

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants